Skip to content

Refuse an evicted-read decision replay whose anchor was recovered by digest - #784

Merged
lavaman131 merged 1 commit into
mainfrom
fix-776-stale-replay-twin
Nov 4, 2026
Merged

lavaman131 merged 1 commit into
mainfrom
fix-776-stale-replay-twin

Conversation

@lavaman131

Copy link
Copy Markdown
Collaborator

Summary

Without the Planner's retained read, a stale open_decisions replay is now refused, not reported as captured, when its card's anchor was recovered by digest onto an identical copy of a rewritten paragraph, including when the copy is the block right before the card's widget. Replays that were correctly accepted stay accepted: a card beside a paragraph that kept its place, with the read retained, evicted or lost to a restart.

Root cause

Without the read, repeats() in apps/server/src/questions/service-publish.ts accepted a replay for a card standing beside the single passage it was opened on (position === 0 && standing === index). When one edit rewrote the paragraph and left an identical copy of its old text between it and the card, the card's anchor was recovered by digest onto the copy, which is also the block before the card's widget. Both checks agreed, so the key was reported as captured. With the read retained the same replay was already refused.

A first fix asked only whether the stored anchor still resolved by position. That was not enough, and the test that accompanied it hid why. Every production edit path (the Planner's edit_plan, a browser plan:update, a restart in Plan.open) runs Questions.rebase right after the edit, and room.rebase re-expresses a digest-recovered anchor as a fresh relative position on the copy and stores it. By the time of the replay the anchor resolved by position again, so the replay was still accepted. The test edited with Plan.publish, which skips that rebase, so it exercised a state no production edit leaves behind.

Fix

The fact that an anchor was recovered by digest has to survive the rebase, so it is recorded:

  • Plan.Anchor has an optional byDigest flag (packages/protocol/plan.d.ts). It is stored in the card's anchors in the sidecar record, which restores without validating them, so old data has no flag and nothing else changes.
  • room.rebase takes { marking: true }. With it, an anchor found again by digest is marked, and an anchor that already carries the mark keeps it when it resolves by position. Only the question relationship rebase (Relationships.carry) and repeats() ask for it; comment, prose and follow-up anchors are rebased exactly as before.
  • Without the read, repeats() refuses a card whose anchor carries the mark. It also marks on the fly, so a stored anchor that no longer resolves when no rebase has run yet is refused too.
  • The refusal keeps its cause, "the server no longer has that read", and adds that the card's paragraph can only be found again by its text, which an identical copy would match too.
  • The mark ends only when the Planner anchors the card again (anchor_plan), which replaces the anchor. docs/decisions.md states the rule.

Tests

In apps/server/src/questions/capture-decisions.test.ts, through production paths: applyBlockEdit (the Planner's edit_plan, including its rebase and commit), a real browser plan:update through Plan.submit and Plan.drain, and Plan.close/Plan.open:

  • Red before the fix (/tmp/flakes/chopin-776-red2.txt): with the source change stashed, a rewrite plus a copy just before the card followed by nine later outlines was accepted as captured through applyBlockEdit and through a browser update; the same after a restart following a Planner edit, and after a restart following a browser edit, later edits and another restart. A fifth test (the stored anchor not yet rebased) failed for lacking the new cause. All 44 tests pass with the fix.
  • Mutation check: dropping the mark's carry in the positional branch of room.rebase fails 4 tests; not marking in the question rebase fails 4; not marking at replay time fails 5.
  • The same replay with the read retained is refused; the replay of an unchanged paragraph is accepted once with the read retained, evicted by nine outlines, and after a restart; a card whose paragraph keeps its place through an insertion above, an edit, a deletion and a browser note is accepted without its read, also after a restart.
  • bun test apps/server (4140 pass), bun test packages apps/web apps/connector scripts (2813 pass), bun run fix, bun run types, bun run ci, and bun run e2e planner-decisions harness-decisions-deck mcp-update (11 pass) are green. bun test apps/server/src/storage/postgres passed (124) on a disposable PostgreSQL 17 at port 55432 because the port bun run test:postgres uses was taken; the marker's restart is covered with the memory adapter only.

Limits

  • The server cannot tell a copy from its paragraph once the anchor's position is lost, so any edit that takes a card's position marks it, not only a rewrite beside a copy: replacing the whole document, moving another block across the paragraph (the editor binding recreates the paragraph's node), or a rebuild or compaction that starts a new epoch. After one of these, a replay without its read is refused until the Planner reads again, even though no copy exists. The refusal is recoverable in one step: a fresh read gives the replay a read to be judged against. Insertions, deletions, edits in place and typing elsewhere keep a card's position.
  • A card whose anchor was already re-expressed onto a copy before this change has no mark, so the replay is judged by where the card stands, as before.
  • The mark is sticky until the Planner anchors the card again.

Fixes #776

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 9570775f-38b7-4817-859f-1fd90b98cd5f)
Assistant-duration: 40m converged, estimated 1h
Assistant-verification: red before fix passed: with the source change stashed, the new replay tests failed (a rewrite plus copy through applyBlockEdit, through a browser plan:update, and after two restarts were all accepted as captured; one more lacked the new cause), and all 44 capture-decisions tests pass with it
Assistant-verification: mutation check passed: dropping the mark's carry in the positional branch of the rebase failed 4 tests, not marking in the question rebase failed 4, and not marking at replay time failed 5
Assistant-verification: bun test apps/server passed: 4140 pass, 0 fail, 6 skipped without a database
Assistant-verification: bun test packages apps/web apps/connector scripts passed: 2813 pass, 0 fail
Assistant-verification: bun run fix passed: no changes, 72 existing lint warnings
Assistant-verification: bun run types passed: whole workspace and e2e
Assistant-verification: bun run ci passed: dprint, oxlint, tokens and design checks
Assistant-verification: bun run e2e planner-decisions harness-decisions-deck mcp-update passed: 11 tests
Assistant-verification: bun test apps/server/src/storage/postgres passed: 124 pass on a disposable PostgreSQL 17 at port 55432, because the 5432 that bun run test:postgres uses was taken; the marker's restart is covered with the memory adapter only
Co-authored-by: Alex Lavaee lavaman131@github.com

…digest

Without the Planner's retained read, a replay of an `open_decisions` key was accepted
whenever the card stood right after a block carrying the replayed digest. When one edit
rewrote the paragraph the card was opened on and left an identical copy of its old text
between the rewritten paragraph and the card, the card's anchor was recovered by digest
onto that copy, which is also the block before the card's widget, so the replay was
reported as captured. With the read retained the same replay was refused.

Every edit rebases the cards' anchors, and a rebase re-expresses an anchor it recovered by
digest as a fresh position on the block it found. Asking afterwards whether the anchor still
resolves by position therefore cannot tell a copy from the paragraph. The rebase of a card's
relationship anchors now marks an anchor it found again by digest (`byDigest`, an optional
field of the anchor in the card's stored record), keeps the mark through later rebases and a
restart, and drops it only when the Planner anchors the card again. Comment, prose and
follow-up anchors are rebased as before.

Without the read, a replay is refused for a card whose anchor carries the mark, or whose
stored anchor no longer resolves, with the cause that the card's paragraph can only be found
again by its text. A card beside a paragraph that kept its place through insertions,
deletions, edits and typing elsewhere is still accepted, with the read retained, evicted or
lost to a restart. An edit that takes a card's position for any other reason (replacing the
whole document, moving another block across the paragraph, a rebuilt epoch) marks it too,
so such a replay is refused until the Planner reads again; a record stored before the mark
existed has none. The decisions guide says so.

Fixes #776

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 9570775f-38b7-4817-859f-1fd90b98cd5f)
Assistant-duration: 40m converged, estimated 1h
Assistant-verification: red before fix passed: with the source change stashed, the new replay tests failed (a rewrite plus copy through applyBlockEdit, through a browser plan:update, and after two restarts were all accepted as captured; one more lacked the new cause), and all 44 capture-decisions tests pass with it
Assistant-verification: mutation check passed: dropping the mark's carry in the positional branch of the rebase failed 4 tests, not marking in the question rebase failed 4, and not marking at replay time failed 5
Assistant-verification: bun test apps/server passed: 4140 pass, 0 fail, 6 skipped without a database
Assistant-verification: bun test packages apps/web apps/connector scripts passed: 2813 pass, 0 fail
Assistant-verification: bun run fix passed: no changes, 72 existing lint warnings
Assistant-verification: bun run types passed: whole workspace and e2e
Assistant-verification: bun run ci passed: dprint, oxlint, tokens and design checks
Assistant-verification: bun run e2e planner-decisions harness-decisions-deck mcp-update passed: 11 tests
Assistant-verification: bun test apps/server/src/storage/postgres passed: 124 pass on a disposable PostgreSQL 17 at port 55432, because the 5432 that bun run test:postgres uses was taken; the marker's restart is covered with the memory adapter only
Co-authored-by: Alex Lavaee <lavaman131@github.com>
@lavaman131
lavaman131 force-pushed the fix-776-stale-replay-twin branch from 8808f7f to e7c8a30 Compare November 4, 2026 19:52
@lavaman131
lavaman131 merged commit 8f8c4dd into main Nov 4, 2026
24 checks passed
@lavaman131
lavaman131 deleted the fix-776-stale-replay-twin branch November 4, 2026 19:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A stale decision replay without its read is accepted beside a copy of a rewritten paragraph

1 participant